package com.mimosa.util.config.ini;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

/**
 * Loads, edits and saves INI-style configuration files. While loading from and
 * saving to streams and files, <code>IniEditor</code> preserves comments and
 * blank lines as well as the order of sections and lines in general.
 * <p>
 * <code>IniEditor</code> assumes configuration files to be split in sections. A
 * section starts out with a header, which consists of the section name enclosed
 * in brackets (<code>'['</code> and <code>']'</code>). Everything before the
 * first section header is ignored when loading from a stream or file. The
 * <code>{@link IniEditor.Section}</code> class can be used to load
 * configuration files without sections (ie Java-style properties).
 * <p>
 * A "common section" may be named. All sections inherit the options of this
 * section but can overwrite them.
 * <p>
 * <code>IniEditor</code> represents an INI file (or rather, its sections) line
 * by line, as comment, blank and option lines. A comment is a line which has a
 * comment delimiter as its first non-white space character. The default comment
 * delimiters, which may be overwritten, are <code>'#'</code> and
 * <code>';'</code>.
 * <p>
 * A blank line is any line that consists only of white space.
 * <p>
 * Everything else is an option line. Option names and values are separated by
 * option delimiters <code>'='</code>, <code>':'</code> or white space (spaces
 * and tabs).
 * <p>
 * Here's a minimal example. Suppose, we have this in a file called
 * <code>users.ini</code>:
 * 
 * <pre>
 *   [root]
 *   role = administrator
 *   last_login = 2003-05-04
 * 
 *   [joe]
 *   role = author
 *   last_login = 2003-05-13
 * </pre>
 * 
 * Let's load that file, add something to it and save the changes:
 * 
 * <pre>
 * IniEditor users = new IniEditor();
 * users.load(&quot;users.ini&quot;);
 * users.set(&quot;root&quot;, &quot;last_login&quot;, &quot;2003-05-16&quot;);
 * users.addComment(&quot;root&quot;, &quot;Must change password often&quot;);
 * users.set(&quot;root&quot;, &quot;change_pwd&quot;, &quot;10 days&quot;);
 * users.addBlankLine(&quot;root&quot;);
 * users.save(&quot;users.ini&quot;);
 * </pre>
 * 
 * Now, the file looks like this:
 * 
 * <pre>
 *   [root]
 *   role = administrator
 *   last_login = 2003-05-16
 * 
 *   # Must change password often
 *   change_pwd = 10 days
 * 
 *   [joe]
 *   role = author
 *   last_login = 2003-05-13
 * </pre>
 * <p>
 * IniEditor provides services simliar to the standard Java API class
 * <code>java.util.Properties</code>. It uses its own parser, though, which
 * differs in these respects from that of <code>Properties</code>:
 * <ul>
 * <li>Line continuations (backslashes at the end of an option line) are not
 * supported.</li>
 * <li>No kind of character escaping is performed or recognized. Characters are
 * read and written in in the default character encoding. If you want to use a
 * different character encoding, use the {@link #load(InputStreamReader)} and
 * {@link #save(OutputStreamWriter)} methods with a reader and writer tuned to
 * the desired character encoding.</li>
 * <li>As a consequence, option names may not contain option/value separators
 * (normally <code>'='</code>, <code>':'</code> and white space).</li>
 * </ul>
 * 
 * @author Nik Haldimann, me at ubique.ch
 * @version r4 (8/10/2005)
 * @revision $Id$
 */
public class IniEditor {
	private static boolean DEFAULT_CASE_SENSITIVITY = false;

	private Map<String, Section> sections;
	private List<String> sectionOrder;
	private String commonName;
	private char[] commentDelims;
	private boolean isCaseSensitive;
	private OptionFormat optionFormat;

	/**
	 * Constructs new bare IniEditor instance.
	 */
	public IniEditor() {
		this(null, null);
	}

	/**
	 * Constructs new bare IniEditor instance specifying case-sensitivity.
	 * 
	 * @param isCaseSensitive
	 *            section and option names are case-sensitive if this is true
	 */
	public IniEditor(boolean isCaseSensitive) {
		this(null, null, isCaseSensitive);
	}

	/**
	 * Constructs new IniEditor instance with a common section. Options in the
	 * common section are used as defaults for all other sections.
	 * 
	 * @param commonName
	 *            name of the common section
	 */
	public IniEditor(String commonName) {
		this(commonName, null);
	}

	/**
	 * Constructs new IniEditor instance with a common section. Options in the
	 * common section are used as defaults for all other sections.
	 * 
	 * @param commonName
	 *            name of the common section
	 * @param isCaseSensitive
	 *            section and option names are case-sensitive if this is true
	 */
	public IniEditor(String commonName, boolean isCaseSensitive) {
		this(commonName, null, isCaseSensitive);
	}

	/**
	 * Constructs new IniEditor defining comment delimiters.
	 * 
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 */
	public IniEditor(char[] delims) {
		this(null, delims);
	}

	/**
	 * Constructs new IniEditor defining comment delimiters.
	 * 
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 * @param isCaseSensitive
	 *            section and option names are case-sensitive if this is true
	 */
	public IniEditor(char[] delims, boolean isCaseSensitive) {
		this(null, delims, isCaseSensitive);
	}

	/**
	 * Constructs new IniEditor instance with a common section, defining comment
	 * delimiters. Options in the common section are used as defaults for all
	 * other sections.
	 * 
	 * @param commonName
	 *            name of the common section
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 */
	public IniEditor(String commonName, char[] delims) {
		this(commonName, delims, DEFAULT_CASE_SENSITIVITY);
	}

	/**
	 * Constructs new IniEditor instance with a common section, defining comment
	 * delimiters. Options in the common section are used as defaults for all
	 * other sections.
	 * 
	 * @param commonName
	 *            name of the common section
	 * @param delims
	 *            an array of characters to be recognized as starters of comment
	 *            lines; the first of them will be used for newly created
	 *            comments
	 */
	public IniEditor(String commonName, char[] delims, boolean isCaseSensitive) {
		this.sections = new HashMap<String, Section>();
		this.sectionOrder = new LinkedList<String>();
		this.isCaseSensitive = isCaseSensitive;
		if (commonName != null) {
			this.commonName = commonName;
			addSection(this.commonName);
		}
		this.commentDelims = delims;
		this.optionFormat = new OptionFormat(Section.DEFAULT_OPTION_FORMAT);
	}

	/**
	 * Sets the option format for this instance to the given string. Options
	 * will be rendered according to the given format string when printed. The
	 * string must contain <code>%s</code> three times, these will be replaced
	 * with the option name, the option separator and the option value in this
	 * order. Literal percentage signs must be escaped by preceding them with
	 * another percentage sign (i.e., <code>%%</code> corresponds to one
	 * percentage sign). The default format string is <code>"%s %s %s"</code>.
	 * 
	 * Option formats may look like format strings as supported by Java 1.5, but
	 * the string is in fact parsed in a custom fashion to guarantee backwards
	 * compatibility. So don't try clever stuff like using format conversion
	 * types other than <code>%s</code>.
	 * 
	 * @param formatString
	 *            a format string, containing <code>%s</code> exactly three
	 *            times
	 * @throws IllegalArgumentException
	 *             if the format string is illegal
	 */
	public void setOptionFormatString(String formatString) {
		this.optionFormat = new OptionFormat(formatString);
	}

	/**
	 * Returns the value of a given option in a given section or null if either
	 * the section or the option don't exist. If a common section was defined
	 * options are also looked up there if they're not present in the specific
	 * section.
	 * 
	 * @param section
	 *            the section's name
	 * @param option
	 *            the option's name
	 * @return the option's value
	 * @throws NullPointerException
	 *             any of the arguments is <code>null</code>
	 */
	public String get(String section, String option) {
		if (hasSection(section)) {
			Section sect = getSection(section);
			if (sect.hasOption(option)) {
				return sect.get(option);
			}
			if (this.commonName != null) {
				return getSection(this.commonName).get(option);
			}
		}
		return null;
	}

	/**
	 * Sets the value of an option in a section, if the option exist, otherwise
	 * adds the option to the section. Trims white space from the start and the
	 * end of the value and deletes newline characters it might contain.
	 * 
	 * @param section
	 *            the section's name
	 * @param option
	 *            the option's name
	 * @param value
	 *            the option's value
	 * @throws IniEditor.NoSuchSectionException
	 *             no section with the given name exists
	 * @throws IllegalArgumentException
	 *             the option name is illegal, ie contains a '=' character or
	 *             consists only of white space
	 * @throws NullPointerException
	 *             section or option are <code>null</code>
	 */
	public void set(String section, String option, String value) {
		if (hasSection(section)) {
			getSection(section).set(option, value);
		} else {
			throw new NoSuchSectionException(section);
		}
	}

	/**
	 * Removes an option from a section if it exists. Will not remove options
	 * from the common section if it's not directly addressed.
	 * 
	 * @param section
	 *            the section's name
	 * @param option
	 *            the option's name
	 * @return <code>true</code> if the option was actually removed
	 * @throws IniEditor.NoSuchSectionException
	 *             no section with the given name exists
	 */
	public boolean remove(String section, String option) {
		if (hasSection(section)) {
			return getSection(section).remove(option);
		} else {
			throw new NoSuchSectionException(section);
		}
	}

	/**
	 * Checks whether an option exists in a given section. Options in the common
	 * section are assumed to not exist in particular sections, unless they're
	 * overwritten.
	 * 
	 * @param section
	 *            the section's name
	 * @param option
	 *            the option's name
	 * @return true if the given section has the option
	 */
	public boolean hasOption(String section, String option) {
		return hasSection(section) && getSection(section).hasOption(option);
	}

	/**
	 * Checks whether a section with a particular name exists in this instance.
	 * 
	 * @param name
	 *            the name of the section
	 * @return true if the section exists
	 */
	public boolean hasSection(String name) {
		return this.sections.containsKey(normSection(name));
	}

	/**
	 * Adds a section if it doesn't exist yet.
	 * 
	 * @param name
	 *            the name of the section
	 * @return <code>true</code> if the section didn't already exist
	 * @throws IllegalArgumentException
	 *             the name is illegal, ie contains one of the characters '['
	 *             and ']' or consists only of white space
	 */
	public boolean addSection(String name) {
		String normName = normSection(name);
		if (!hasSection(normName)) {
			// Section constructor might throw IllegalArgumentException
			Section section = new Section(normName, this.commentDelims, this.isCaseSensitive);
			section.setOptionFormat(this.optionFormat);
			this.sections.put(normName, section);
			this.sectionOrder.add(normName);
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Removes a section if it exists.
	 * 
	 * @param name
	 *            the section's name
	 * @return <code>true</code> if the section actually existed
	 * @throws IllegalArgumentException
	 *             when trying to remove the common section
	 */
	public boolean removeSection(String name) {
		String normName = normSection(name);
		if (this.commonName != null && this.commonName.equals(normName)) {
			throw new IllegalArgumentException("Can't remove common section");
		}
		if (hasSection(normName)) {
			this.sections.remove(normName);
			this.sectionOrder.remove(normName);
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Returns all section names in this instance minus the common section if
	 * one was defined.
	 * 
	 * @return list of the section names in original/insertion order
	 */
	public List<String> sectionNames() {
		List<String> sectList = new ArrayList<String>(this.sectionOrder);
		if (this.commonName != null) {
			sectList.remove(this.commonName);
		}
		return sectList;
	}

	/**
	 * Returns all option names of a section, not including options from the
	 * common section.
	 * 
	 * @param section
	 *            the section's name
	 * @return list of option names
	 * @throws IniEditor.NoSuchSectionException
	 *             no section with the given name exists
	 */
	public List<String> optionNames(String section) {
		if (hasSection(section)) {
			return getSection(section).optionNames();
		} else {
			throw new NoSuchSectionException(section);
		}
	}

	/**
	 * Adds a comment line to the end of a section. A comment spanning several
	 * lines (ie with line breaks) will be split up, one comment line for each
	 * line.
	 * 
	 * @param section
	 *            the section's name
	 * @param comment
	 *            the comment
	 * @throws IniEditor.NoSuchSectionException
	 *             no section with the given name exists
	 */
	public void addComment(String section, String comment) {
		if (hasSection(section)) {
			getSection(section).addComment(comment);
		} else {
			throw new NoSuchSectionException(section);
		}
	}

	/**
	 * Adds a blank line to the end of a section.
	 * 
	 * @param section
	 *            the section's name
	 * @throws IniEditor.NoSuchSectionException
	 *             no section with the given name exists
	 */
	public void addBlankLine(String section) {
		if (hasSection(section)) {
			getSection(section).addBlankLine();
		} else {
			throw new NoSuchSectionException(section);
		}
	}

	/**
	 * Writes this instance in INI format to a file.
	 * 
	 * @param filename
	 *            the file to write to
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void save(String filename) throws IOException {
		save(new File(filename));
	}

	/**
	 * Writes this instance in INI format to a file.
	 * 
	 * @param file
	 *            where to save to
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void save(File file) throws IOException {
		OutputStream out = new FileOutputStream(file);
		save(out);
		out.close();
	}

	/**
	 * Writes this instance in INI format to an output stream. This method takes
	 * an <code>OutputStream</code> for maximum flexibility, internally it does
	 * of course use a writer for character based output.
	 * 
	 * @param stream
	 *            where to write
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void save(OutputStream stream) throws IOException {
		save(new OutputStreamWriter(stream));
	}

	/**
	 * Writes this instance in INI format to an output stream writer.
	 * 
	 * @param streamWriter
	 *            where to write
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void save(OutputStreamWriter streamWriter) throws IOException {
		Iterator<String> it = this.sectionOrder.iterator();
		PrintWriter writer = new PrintWriter(streamWriter, true);
		while (it.hasNext()) {
			Section sect = getSection((String) it.next());
			writer.println(sect.header());
			sect.save(writer);
		}
	}

	/**
	 * Loads INI formatted input from a file into this instance, using the
	 * default character encoding. Everything in the file before the first
	 * section header is ignored.
	 * 
	 * @param filename
	 *            file to read from
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void load(String filename) throws IOException {
		load(new File(filename));
	}

	/**
	 * Loads INI formatted input from a file into this instance, using the
	 * default character encoding. Everything in the file before the first
	 * section header is ignored.
	 * 
	 * @param file
	 *            file to read from
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void load(File file) throws IOException {
		InputStream in = new FileInputStream(file);
		load(in);
		in.close();
	}

	/**
	 * Loads INI formatted input from a stream into this instance, using the
	 * default character encoding. This method takes an <code>InputStream</code>
	 * for maximum flexibility, internally it does use a reader (using the
	 * default character encoding) for character based input. Everything in the
	 * stream before the first section header is ignored.
	 * 
	 * @param stream
	 *            where to read from
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void load(InputStream stream) throws IOException {
		load(new InputStreamReader(stream));
	}

	/**
	 * Loads INI formatted input from a stream reader into this instance.
	 * Everything in the stream before the first section header is ignored.
	 * 
	 * @param streamReader
	 *            where to read from
	 * @throws IOException
	 *             at an I/O problem
	 */
	public void load(InputStreamReader streamReader) throws IOException {
		BufferedReader reader = new BufferedReader(streamReader);
		String curSection = null;
		String line = null;

		while (reader.ready()) {
			line = reader.readLine().trim();
			if (line.length() > 0 && line.charAt(0) == Section.HEADER_START) {
				int endIndex = line.indexOf(Section.HEADER_END);
				if (endIndex >= 0) {
					curSection = line.substring(1, endIndex);
					addSection(curSection);
				}
			}
			if (curSection != null) {
				Section sect = getSection(curSection);
				sect.load(reader);
			}
		}
	}

	/**
	 * Returns a section by name or <code>null</code> if not found.
	 * 
	 * @param name
	 *            the section's name
	 * @return the section
	 */
	private Section getSection(String name) {
		return (Section) sections.get(normSection(name));
	}

	/**
	 * Normalizes an arbitrary string for use as a section name. Currently only
	 * makes the string lower-case (provided this instance isn't case-
	 * sensitive) and trims leading and trailing white space. Note that
	 * normalization isn't enforced by the Section class.
	 * 
	 * @param name
	 *            the string to be used as section name
	 * @return a normalized section name
	 */
	private String normSection(String name) {
		if (!this.isCaseSensitive) {
			name = name.toLowerCase();
		}
		return name.trim();
	}

	private static String[] toStringArray(Collection<Object> coll) {
		Object[] objArray = coll.toArray();
		String[] strArray = new String[objArray.length];
		for (int i = 0; i < objArray.length; i++) {
			strArray[i] = (String) objArray[i];
		}
		return strArray;
	}
}